/*******************************************************************************
 * Copyright (c) 2000, 2016 IBM Corporation and others.
 *
 * This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License 2.0
 * which accompanies this distribution, and is available at
 * https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors:
 *     IBM Corporation - initial API and implementation
 *     Felix Pahl (fpahl@web.de) - fixed https://bugs.eclipse.org/bugs/show_bug.cgi?id=51820
 *******************************************************************************/

package org.eclipse.ui.texteditor;

import java.util.Stack;

import org.eclipse.swt.SWT;
import org.eclipse.swt.custom.StyledText;
import org.eclipse.swt.custom.VerifyKeyListener;
import org.eclipse.swt.events.FocusEvent;
import org.eclipse.swt.events.FocusListener;
import org.eclipse.swt.events.MouseEvent;
import org.eclipse.swt.events.MouseListener;
import org.eclipse.swt.events.VerifyEvent;
import org.eclipse.swt.graphics.Color;
import org.eclipse.swt.graphics.Point;

import org.eclipse.core.commands.ExecutionEvent;
import org.eclipse.core.commands.ExecutionException;
import org.eclipse.core.commands.IExecutionListener;
import org.eclipse.core.commands.NotHandledException;

import org.eclipse.core.runtime.Assert;

import org.eclipse.jface.action.IStatusLineManager;
import org.eclipse.jface.util.Util;
import org.eclipse.jface.viewers.ISelection;
import org.eclipse.jface.viewers.ISelectionChangedListener;
import org.eclipse.jface.viewers.ISelectionProvider;
import org.eclipse.jface.viewers.SelectionChangedEvent;

import org.eclipse.jface.text.IFindReplaceTarget;
import org.eclipse.jface.text.IFindReplaceTargetExtension;
import org.eclipse.jface.text.IRegion;
import org.eclipse.jface.text.ITextListener;
import org.eclipse.jface.text.ITextSelection;
import org.eclipse.jface.text.ITextViewer;
import org.eclipse.jface.text.ITextViewerExtension;
import org.eclipse.jface.text.ITextViewerExtension5;
import org.eclipse.jface.text.TextEvent;

import org.eclipse.ui.PlatformUI;
import org.eclipse.ui.commands.ICommandService;
import org.eclipse.ui.internal.texteditor.NLSUtility;
import org.eclipse.ui.keys.IBindingService;

/**
 * An incremental find target. Replace is always disabled.
 * @since 2.0
 */
class IncrementalFindTarget implements IFindReplaceTarget, IFindReplaceTargetExtension, VerifyKeyListener, MouseListener, FocusListener, ISelectionChangedListener, ITextListener, IExecutionListener {

	/** The string representing rendered tab */
	private final static String TAB= EditorMessages.Editor_FindIncremental_render_tab;
	/**
	 * The string representing "Reverse Incremental Find"
	 * @since 3.0
	 */
	private final static String FIELD_NAME= EditorMessages.Editor_FindIncremental_name;
	/**
	 * The string representing "Incremental Find"
	 * @since 3.0
	 */
	private final static String REVERSE_FIELD_NAME= EditorMessages.Editor_FindIncremental_reverse_name;
	/**
	 * The string representing reverse
	 * @since 2.1
	 */
	private final static String REVERSE= EditorMessages.Editor_FindIncremental_reverse;
	/**
	 * The string representing wrapped
	 * @since 2.1
	 */
	private final static String WRAPPED= EditorMessages.Editor_FindIncremental_wrapped;
	/** The text viewer to operate on */
	private final ITextViewer fTextViewer;
	/** The status line manager for output */
	private final IStatusLineManager fStatusLine;
	/** The find replace target to delegate find requests */
	private final IFindReplaceTarget fTarget;
	/** The current find string */
	private StringBuilder fFindString= new StringBuilder();
	/** The position of the first upper case character, -1 if none */
	private int fCasePosition;
	/**
	 * The position in the stack of the first wrap search, -1 if none
	 * @since 2.1
	 */
	private int fWrapPosition;
	/** The position of the last successful find */
	private int fCurrentIndex;
	/** A flag indicating if last find was successful */
	private boolean fFound;
	/**
	 * A flag indicating if the last search was forward
	 * @since 2.1
	 */
	private boolean fForward= true;
	/** A flag indicating listeners are installed. */
	private boolean fInstalled;
	/**
	 * A flag indicating that a search is currently active.
	 * Used to ignore selection callbacks generated by the incremental search itself.
	 * @since 2.1
	 */
	private boolean fSearching;
	/** The current find stack */
	private Stack<SearchResult> fSessionStack;
	/**
	 * The previous search string
	 * @since 2.1
	 */
	private String fPrevFindString= ""; //$NON-NLS-1$
	/**
	 * The previous position of the first upper case character, -1 if none
	 * @since 3.0
	 */
	private int fPrevCasePosition= -1;
	/**
	 * The find status field.
	 * @since 3.0
	 */
	private IStatusField fStatusField;
	/**
	 * Tells whether the status field implements
	 * <code>IStatusFieldExtension</code>.
	 * @see IStatusFieldExtension
	 * @since 3.0
	 */
	private boolean fIsStatusFieldExtension;

	/**
	 * Data structure for a search result.
	 * @since 2.1
	 */
	private class SearchResult {
		int selection, length, index, findLength;
		boolean found, forward;

		/**
		 * Creates a new search result data object and fills
		 * it with the current values of this target.
		 */
		public SearchResult() {
			Point p= fTarget.getSelection();
			selection= p.x;
			length= p.y;
			index= fCurrentIndex;
			findLength= fFindString.length();
			found= fFound;
			forward= fForward;
		}

	}

	/**
	 * Stores the search result.
	 */
	private void saveState() {
		fSessionStack.push(new SearchResult());
	}

	/**
	 * Restores the search result.
	 *
	 * @since 2.1
	 */
	private void restoreState() {

		StyledText text= fTextViewer.getTextWidget();
		if (text == null || text.isDisposed())
			return;

		SearchResult searchResult= null;
		if (!fSessionStack.empty())
			searchResult= fSessionStack.pop();

		if (searchResult == null) {
			text.getDisplay().beep();
			return;
		}

		text.setSelectionRange(searchResult.selection, searchResult.length);
		text.showSelection();

		// relies on the contents of the StringBuilder
		fFindString.setLength(searchResult.findLength);
		fCurrentIndex= searchResult.index;
		fFound= searchResult.found;
		fForward= searchResult.forward;

		// Recalculate the indices
		if (fFindString.length() <= fCasePosition)
			fCasePosition= -1;
		if (fSessionStack.size() < fWrapPosition)
			fWrapPosition= -1;
	}

	/**
	 * Sets the direction for the next search.
	 * This can be called before <code>beginSession</code> to set the initial search direction.
	 * @param forward <code>true</code> if the next search should be forward
	 * @see #beginSession()
	 * @since 2.1
	 */
	public void setDirection(boolean forward) {
		fForward= forward;
	}

	/**
	 * Creates an instance of an incremental find target.
	 * @param viewer the text viewer to operate on
	 * @param manager the status line manager for output
	 */
	public IncrementalFindTarget(ITextViewer viewer, IStatusLineManager manager) {
		Assert.isNotNull(viewer);
		Assert.isNotNull(manager);
		fTextViewer= viewer;
		fStatusLine= manager;
		fTarget= viewer.getFindReplaceTarget();
	}

	@Override
	public boolean canPerformFind() {
		return fTarget.canPerformFind();
	}

	@Override
	public int findAndSelect(int offset, String findString, boolean searchForward, boolean caseSensitive, boolean wholeWord) {
		return fTarget.findAndSelect(offset, findString, searchForward, caseSensitive, wholeWord);
	}

	@Override
	public Point getSelection() {
		return fTarget.getSelection();
	}

	@Override
	public String getSelectionText() {
		return fTarget.getSelectionText();
	}

	@Override
	public boolean isEditable() {
		return false;
	}

	@Override
	public void replaceSelection(String text) {
	}

	@Override
	public void beginSession() {
		fSearching= true;

		// Workaround since some accelerators get handled directly by the OS
		if (fInstalled) {
			saveState();
			repeatSearch(fForward);
			updateStatus();
			fSearching= false;
			return;
		}

		fFindString.setLength(0);
		fSessionStack= new Stack<>();
		fCasePosition= -1;
		fWrapPosition= -1;
		fFound= true;

		// clear initial selection
		StyledText text= fTextViewer.getTextWidget();
		if (text != null && !text.isDisposed()) {
			fCurrentIndex= text.getCaretOffset();
			text.setSelection(fCurrentIndex);
		} else {
			fCurrentIndex= 0;
		}

		install();

		// Set the mark
		if (fTextViewer instanceof ITextViewerExtension) {
			int modelOffset;
			if (fTextViewer instanceof ITextViewerExtension5)
				modelOffset= fCurrentIndex == -1 ? -1 : ((ITextViewerExtension5)fTextViewer).widgetOffset2ModelOffset(fCurrentIndex);
			else
				modelOffset= fCurrentIndex;
			((ITextViewerExtension)fTextViewer).setMark(modelOffset);
		}

		updateStatus();

		if (fTarget instanceof IFindReplaceTargetExtension)
			((IFindReplaceTargetExtension) fTarget).beginSession();

		fSearching= false;
	}

	@Override
	public void endSession() {
		if (fTarget instanceof IFindReplaceTargetExtension)
			((IFindReplaceTargetExtension) fTarget).endSession();

		// will uninstall itself
	}

	@Override
	public IRegion getScope() {
		return null;
	}

	@Override
	public void setScope(IRegion scope) {
	}

	@Override
	public void setReplaceAllMode(boolean replaceAll) {
	}

	/**
	 * Installs this target. I.e. adds all required listeners.
	 */
	private void install() {

		if (fInstalled)
			return;

		StyledText text= fTextViewer.getTextWidget();
		if (text == null)
			return;

		text.addMouseListener(this);
		text.addFocusListener(this);
		fTextViewer.addTextListener(this);

		ISelectionProvider selectionProvider= fTextViewer.getSelectionProvider();
		if (selectionProvider != null)
			selectionProvider.addSelectionChangedListener(this);

		if (fTextViewer instanceof ITextViewerExtension)
			((ITextViewerExtension) fTextViewer).prependVerifyKeyListener(this);
		else
			text.addVerifyKeyListener(this);

		ICommandService commandService= PlatformUI.getWorkbench().getAdapter(ICommandService.class);
		if (commandService != null)
			commandService.addExecutionListener(this);

		fInstalled= true;
	}

	/**
	 * Uninstalls itself. I.e. removes all listeners installed in <code>install</code>.
	 */
	private void uninstall() {

		fTextViewer.removeTextListener(this);

		ISelectionProvider selectionProvider= fTextViewer.getSelectionProvider();
		if (selectionProvider != null)
			selectionProvider.removeSelectionChangedListener(this);

		StyledText text= fTextViewer.getTextWidget();
		if (text != null) {
			text.removeMouseListener(this);
			text.removeFocusListener(this);
		}

		if (fTextViewer instanceof ITextViewerExtension) {
			((ITextViewerExtension) fTextViewer).removeVerifyKeyListener(this);

		} else {
			if (text != null)
				text.removeVerifyKeyListener(this);
		}

		ICommandService commandService= PlatformUI.getWorkbench().getAdapter(ICommandService.class);
		if (commandService != null)
			commandService.removeExecutionListener(this);

		fInstalled= false;
	}

	/**
	 * Updates the status line.
	 * @since 2.1
	 */
	private void updateStatus() {

		if (!fInstalled)
			return;

		String string= fFindString.toString();
		String wrapPrefix= fWrapPosition == -1 ? "" : WRAPPED; //$NON-NLS-1$
		String reversePrefix= fForward ? "" : REVERSE; //$NON-NLS-1$

		if (!fFound) {
			String pattern= EditorMessages.Editor_FindIncremental_not_found_pattern;
			statusError(NLSUtility.format(pattern, new Object[] { reversePrefix, wrapPrefix, string }));

		} else if (string.isEmpty()) {
			if (fForward)
				statusMessage(FIELD_NAME);
			else
				statusMessage(REVERSE_FIELD_NAME);
		} else if (!fForward || fWrapPosition > -1) {
			String pattern= EditorMessages.Editor_FindIncremental_found_pattern;
			statusMessage(NLSUtility.format(pattern, new Object[] { reversePrefix, wrapPrefix, string }));
		} else {
			statusMessage(string);
		}
	}

	@SuppressWarnings("incomplete-switch")
	@Override
	public void verifyKey(VerifyEvent event) {

		if (!event.doit)
			return;

		fSearching= true;
		if (event.character == 0) {

			switch (event.keyCode) {

			// ALT, CTRL, ARROW_LEFT, ARROW_RIGHT == leave
			case SWT.ARROW_LEFT:
			case SWT.ARROW_RIGHT:
			case SWT.HOME:
			case SWT.END:
			case SWT.PAGE_DOWN:
			case SWT.PAGE_UP:
				leave();
				break;

			case SWT.ARROW_DOWN:
				saveState();
				setDirection(true);
				repeatSearch(fForward);
				event.doit= false;
				break;

			case SWT.ARROW_UP:
				saveState();
				setDirection(false);
				repeatSearch(fForward);
				event.doit= false;
				break;
			}

		// event.character != 0
		} else {

			switch (event.character) {

			// ESC, CR = quit
			case 0x1B:
			case 0x0D:
				leave();
				event.doit= false;
				break;

			// backspace	and delete
			case 0x08:
			case 0x7F:
				restoreState();
				event.doit= false;
				break;

			default:
				int stateMask= event.stateMask;
				if (stateMask == 0
						|| stateMask == SWT.SHIFT
						|| !Util.isMac() && stateMask == (SWT.ALT | SWT.CTRL) // AltGr (see bug 43049)
						|| Util.isMac() && (stateMask == (SWT.ALT | SWT.SHIFT) || stateMask == SWT.ALT) ) { // special chars on Mac (bug 272994)
					saveState();
					addCharSearch(event.character);
					event.doit= false;
				}
				break;
			}
		}
		updateStatus();
		fSearching= false;
	}

	/**
	 * Repeats the last search while possibly changing the direction.
	 *
	 * @param forward <code>true</code> iff the next search should be forward
	 * @return if the search was successful
	 * @since 2.1
	 */
	private boolean repeatSearch(boolean forward) {
		if (fFindString.length() == 0) {
			fFindString= new StringBuilder(fPrevFindString);
			fCasePosition= fPrevCasePosition;
		}

		String string= fFindString.toString();
		if (string.isEmpty()) {
			fFound= true;
			return true;
		}

		StyledText text= fTextViewer.getTextWidget();
		// Cannot use fTarget.getSelection since that does not return which side of the
		// selection the caret is on.
		int startIndex= text.getCaretOffset();
		if (!forward)
			startIndex -= 1;

		// Check to see if a wrap is necessary
		if (!fFound && (fForward == forward)) {
			startIndex= -1;
			if (fWrapPosition == -1)
				fWrapPosition= fSessionStack.size();
		}
		fForward = forward;

		// Find the string
		text.setRedraw(false);
		int index= fTarget.findAndSelect(startIndex, string, fForward, fCasePosition != -1, false);

		// Set the caret on the left if the search is reversed
		if (!forward) {
			Point p= fTarget.getSelection();
			text.setSelectionRange(p.x + p.y, -p.y);
			p= null;
		}
		text.setRedraw(true);

		// Take appropriate action
		boolean found = (index != -1);
		if (!found && fFound) {
			text= fTextViewer.getTextWidget();
			if (text != null && !text.isDisposed())
				text.getDisplay().beep();
		}

		if (found)
			fCurrentIndex= startIndex;

		fFound= found;
		return found;
	}

	/**
	 * Adds the given character to the search string and repeats the search with the last parameters.
	 *
	 * @param c the character to append to the search pattern
	 * @return <code>true</code> the search found a match
	 * @since 2.1
	 */
	private boolean addCharSearch(char c) {
		// Add char to pattern
		if (fCasePosition == -1 && Character.isUpperCase(c) && Character.toLowerCase(c) != c)
			fCasePosition= fFindString.length();

		fFindString.append(c);
		String string= fFindString.toString();
		StyledText text= fTextViewer.getTextWidget();

		text.setRedraw(false);
		int index= fTarget.findAndSelect(fCurrentIndex, string, fForward, fCasePosition != -1, false);

		// Set the caret on the left if the search is reversed
		if (!fForward) {
			Point p= fTarget.getSelection();
			text.setSelectionRange(p.x + p.y, -p.y);
		}
		text.setRedraw(true);

		// Take appropriate action
		boolean found = (index != -1);
		if (!found && fFound) {
			text= fTextViewer.getTextWidget();
			if (text != null && !text.isDisposed())
				text.getDisplay().beep();
		}

		fFound= found;
		return found;
	}

	/**
	 * Leaves this incremental search session.
	 */
	private void leave() {
		if (fFindString.length() != 0) {
			fPrevFindString= fFindString.toString();
			fPrevCasePosition= fCasePosition;
		}
		statusClear();
		uninstall();
		fSessionStack = null;
	}

	@Override
	public void textChanged(TextEvent event) {
		if (event.getDocumentEvent() != null)
			leave();
	}

	/*
	 * @see MouseListener##mouseDoubleClick(MouseEvent)
	 */
	@Override
	public void mouseDoubleClick(MouseEvent e) {
		leave();
	}

	@Override
	public void mouseDown(MouseEvent e) {
		leave();
	}

	@Override
	public void mouseUp(MouseEvent e) {
		leave();
	}

	@Override
	public void focusGained(FocusEvent e) {
	}

	@Override
	public void focusLost(FocusEvent e) {
		IBindingService bindingService= PlatformUI.getWorkbench().getAdapter(IBindingService.class);
		if (bindingService != null && !bindingService.isKeyFilterEnabled()) {
			/*
			 * Workaround for bug 492587: Autosave breaks Incremental Find:
			 * We don't want to leave when the Workbench Window temporarily disables controls to
			 * run an IRunnableWithProgress. There's no direct API to know that this happens, but
			 * we can rely on the implementation detail that WorkbenchWindow#run(..) disables the
			 * key filter (and is the only one who does this, except for the Keys preference page).
			 */
			return;
		}
		leave();
	}

	/**
	 * Sets the given string as status message, clears the status error message.
	 * @param string the status message
	 */
	private void statusMessage(String string) {
		if (fStatusField != null) {
			if (fIsStatusFieldExtension) {
				((IStatusFieldExtension)fStatusField).setErrorText(null);
				fStatusField.setText(escapeTabs(string));
				((IStatusFieldExtension)fStatusField).setVisible(true);
				fStatusLine.update(true);
			} else {
				fStatusLine.setErrorMessage(null);
				fStatusField.setText(escapeTabs(string));
			}
		} else {
			fStatusLine.setErrorMessage(null);
			fStatusLine.setMessage(escapeTabs(string));
		}
	}

	/**
	 * Sets the status error message, clears the status message.
	 * @param string the status error message
	 */
	private void statusError(String string) {
		if (fStatusField != null) {
			if (fIsStatusFieldExtension) {
				((IStatusFieldExtension)fStatusField).setErrorText(escapeTabs(string));
				fStatusField.setText(""); //$NON-NLS-1$
				((IStatusFieldExtension)fStatusField).setVisible(true);
				fStatusLine.update(true);
			} else {
				fStatusLine.setErrorMessage(escapeTabs(string));
				fStatusField.setText(""); //$NON-NLS-1$
			}
		} else {
			fStatusLine.setErrorMessage(escapeTabs(string));
			fStatusLine.setMessage(null);
		}
	}

	/**
	 * Clears the status message and the status error message.
	 */
	private void statusClear() {
		if (fStatusField != null) {
			if (fIsStatusFieldExtension) {
				fStatusField.setText(""); //$NON-NLS-1$
				((IStatusFieldExtension)fStatusField).setErrorText(null);
				((IStatusFieldExtension)fStatusField).setVisible(false);
				fStatusLine.update(true);
			} else {
				fStatusField.setText(""); //$NON-NLS-1$
				fStatusLine.setErrorMessage(null);
			}
		} else {
			fStatusLine.setErrorMessage(null);
			fStatusLine.setMessage(null);
		}
	}

	/**
	 * Translates all tab characters into a proper status line presentation.
	 * @param string the string in which to translate the tabs
	 * @return the given string with all tab characters replace with a proper status line presentation
	 */
	private String escapeTabs(String string) {
		StringBuilder buffer= new StringBuilder();

		int begin= 0;
		int end= string.indexOf('\t', begin);

		while (end >= 0) {
			buffer.append(string.substring(begin, end));
			buffer.append(TAB);
			begin= end + 1;
			end= string.indexOf('\t', begin);
		}
		buffer.append(string.substring(begin));

		return buffer.toString();
	}

	@Override
	public Point getLineSelection() {
		if (fTarget instanceof IFindReplaceTargetExtension)
			return ((IFindReplaceTargetExtension) fTarget).getLineSelection();

		return null; // XXX: should not return null
	}

	@Override
	public void setSelection(int offset, int length) {
		if (fTarget instanceof IFindReplaceTargetExtension)
			((IFindReplaceTargetExtension) fTarget).setSelection(offset, length);
	}

	@Override
	public void setScopeHighlightColor(Color color) {
	}

	@Override
	public void selectionChanged(SelectionChangedEvent e) {
		boolean ignore= false;
		ISelection selection= e.getSelection();
		if (selection instanceof ITextSelection) {
			ITextSelection textSelection= (ITextSelection)selection;
			Point range= getSelection();
			ignore= textSelection.getOffset() + textSelection.getLength() == range.x + range.y;
		}
		if (!fSearching && !ignore)
			leave();
	}

	/**
	 * Sets the find status field for this incremental find target.
	 *
	 * @param statusField the status field
	 * @since 3.0
	 */
	void setStatusField(IStatusField statusField) {
		fStatusField= statusField;
		fIsStatusFieldExtension= fStatusField instanceof IStatusFieldExtension;
	}

	@Override
	public void notHandled(String commandId, NotHandledException exception) {
	}

	@Override
	public void postExecuteFailure(String commandId, ExecutionException exception) {
	}

	@Override
	public void postExecuteSuccess(String commandId, Object returnValue) {
	}

	@Override
	public void preExecute(String commandId, ExecutionEvent event) {
		if (IWorkbenchActionDefinitionIds.FIND_INCREMENTAL.equals(commandId)
				|| IWorkbenchActionDefinitionIds.FIND_INCREMENTAL_REVERSE.equals(commandId))
			return;
		leave();
	}
}
